DocsLive transcription
Live transcription
Stream microphone or tab audio to transcript1 over a WebSocket and get subtitle lines, with optional real-time translation, back as people speak.
Live transcription turns an audio stream into subtitle lines while people are still speaking. Your client sends raw PCM audio over a WebSocket, and transcript1 sends back lines that start as a partial guess and become final. Add translate_to and every line carries its translation too. When the session ends, the result is saved as a normal transcription.
There are no separate live plans: live transcription uses the same minute balance as everything else. See Billing for how connected time is counted.
When to use live
| Use live when | Use a normal transcription when |
|---|---|
| The audio is still being produced: a call, a talk, a stream, a video you are watching. | You have a link or a finished file. |
| You need subtitles within a second or two of the speech. | You want the best accuracy and can wait for the result. |
| You want real-time translation next to each line. | You want a translation of a finished transcript, or several languages. |
Live takes audio only, as raw PCM. It does not accept video links, uploads, WebM, Opus or MP3. Capture the audio in your client and convert it to PCM first. Capture audio in a browser shows how.
Quickstart with the SDK
Install SDK 1.5.0 or newer: npm i @transcript1/sdk@latest. t1.live.start creates the session, connects and resolves once the session has started, so you can send audio right away.
import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
import { Transcript1 } from '@transcript1/sdk';
const t1 = new Transcript1(); // reads TRANSCRIPT1_API_KEY
const live = await t1.live.start({ language: 'en', translateTo: 'es', sampleRate: 16000 });
live.on('line', line => {
const mark = line.final ? 'final ' : 'partial';
console.log(mark, line.text, '→', line.translation?.text ?? '');
});
live.on('warning', warning => console.warn(warning.code));
// speech.pcm: 16 kHz, mono, signed 16-bit little-endian, no header
for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
live.send(chunk);
await sleep(100); // 3,200 bytes are 100 ms of audio: send at real-time speed
}
const result = await live.end();
console.log(result.reason, result.transcriptionId, result.minutesCharged);Lines update in place. Replace a line you already have when one with the same id arrives, or read all of them from live.lines. end() finishes the session, waits for the last lines and saves the transcript. See Node.js SDK for every method and event.
Quickstart with a raw WebSocket
Without the SDK there are two steps: create a session over HTTPS, then open the WebSocket with the single-use token that came back.
Create a session
curl -X POST https://api.transcript1.com/v1/live/sessions \
-H "Authorization: Bearer $TRANSCRIPT1_API_KEY" \
-H "Content-Type: application/json" \
-d '{"language": "en", "translate_to": "es", "sample_rate": 16000}'The API answers 201:
{
"id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
"status": "waiting",
"transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
"websocket_url": "wss://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD/stream",
"token": "Zk3x...",
"token_expires_at": "2026-11-02T12:01:00Z",
"rate": 1
}transcription_id is null when save is false. The token is single-use and expires after 60 seconds, so connect right away. rate is the number of balance minutes charged per started minute of this session; see Billing.
The request body takes these fields. All are optional, and the scope is transcriptions:write:
| Field | Default | Rule |
|---|---|---|
language | auto | auto, or a two-letter code that live transcription supports. |
translate_to | none | A two-letter code. One target language per session. See Translate live. |
diarize | false | Label speakers. Some setups cannot label speakers, for example together with language: "auto", and answer 422. Check capabilities. |
audio_format | pcm_s16le | The only value. The field exists so formats can be added later. |
sample_rate | 16000 | An integer from 8000 to 48000. Mono only. |
save | true | false keeps nothing after the session ends. See Saved transcripts. |
Connect and stream
Open the WebSocket with the token as the second subprotocol. This works in browsers and in Node 22+ without custom headers. Wait for session.started before you send audio: binary frames sent earlier are dropped, and you get an audio_dropped warning.
import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
const session = await fetch('https://api.transcript1.com/v1/live/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TRANSCRIPT1_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ language: 'en', translate_to: 'es', sample_rate: 16000 }),
}).then(response => response.json());
const ws = new WebSocket(session.websocket_url, ['t1-live', session.token]);
const lines = new Map();
ws.addEventListener('message', async message => {
const event = JSON.parse(message.data);
if (event.type === 'session.started') {
for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
ws.send(chunk);
await sleep(100);
}
ws.send(JSON.stringify({ type: 'end' }));
} else if (event.type === 'line') {
lines.set(event.line.id, event.line); // the full current state: replace by id
console.log(event.line.final, event.line.text, event.line.translation?.text);
} else if (event.type === 'session.ended') {
console.log('ended:', event.reason, event.minutes_charged);
}
});
ws.addEventListener('close', event => console.log('closed', event.code));On Node 20 there is no built-in WebSocket: use the ws package, which has the same new WebSocket(url, protocols) form.
The server answers a valid token with the t1-live subprotocol and nothing else. A bad token never fails the handshake: the socket opens, you get an error event and the socket closes with a 4xxx code, so a browser shows the real reason instead of a generic 1006. See Close codes.
Capture audio in a browser
Live audio must be 16-bit signed little-endian mono PCM at the sample_rate of the session. Browsers give you floating-point samples, so convert them in an AudioWorklet. This snippet captures the microphone as 16 kHz PCM in 100 ms chunks and sends it with the browser SDK entry. getAccessToken returns an OAuth access token; never put an API key in a browser. See Security.
import { createLiveClient } from '@transcript1/sdk/live';
const workletSource = `
class Pcm16Processor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Int16Array(1600); // 100 ms at 16 kHz
this.filled = 0;
}
process(inputs) {
const channel = inputs[0][0];
if (!channel) return true;
for (let i = 0; i < channel.length; i++) {
const sample = Math.max(-1, Math.min(1, channel[i]));
this.buffer[this.filled++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
if (this.filled === this.buffer.length) {
this.port.postMessage(this.buffer.slice());
this.filled = 0;
}
}
return true;
}
}
registerProcessor('pcm16', Pcm16Processor);
`;
const client = createLiveClient({ getAccessToken: () => myApp.getOAuthAccessToken() });
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1 } });
const context = new AudioContext({ sampleRate: 16000 });
const moduleUrl = URL.createObjectURL(new Blob([workletSource], { type: 'text/javascript' }));
await context.audioWorklet.addModule(moduleUrl);
// the context's real rate: a browser that cannot do 16 kHz picks its own
const live = await client.start({ language: 'auto', sampleRate: context.sampleRate });
live.on('line', renderLine);
const microphone = context.createMediaStreamSource(stream);
const capture = new AudioWorkletNode(context, 'pcm16');
capture.port.onmessage = event => live.send(event.data); // an Int16Array
const mute = context.createGain();
mute.gain.value = 0;
microphone.connect(capture).connect(mute).connect(context.destination);
async function stop() {
stream.getTracks().forEach(track => track.stop());
await context.close();
return live.end();
}The worklet is written for 16 kHz. If the context runs at another rate, the chunk is no longer 100 ms, but the PCM stays correct because sampleRate matches the context. To capture the sound of a tab or a video instead of the microphone, create the MediaStream with getDisplayMedia or from a media element's captureStream() and connect it the same way.
Translate live
Add translate_to to the session and transcript1 translates every line as it is spoken.
- One target language per session. Add more languages to the saved transcript afterwards with the translations endpoint.
- Every
linehas an original (text,final) and atranslationobject (language,text,final).translationisnullwhentranslate_tois not set. - The original and the translation become final independently: a line can have a final original while its translation is still changing. Draw a line as final once both are.
- A line that is already in the target language uses its own text as the translation, and it is final when the line closes.
- A line closes when the speaker pauses, the speaker changes, a pause between words is longer than 0.9 seconds, the line reaches two subtitle rows (84 characters) or 15 seconds, or you send
finalize. With translation on, a line can take about 3 seconds longer to close while its translation arrives. In that case the translation can stay open (final: false) and keep growing after the original is final. - Translation is free. It uses no minutes.
Which target languages and language pairs are available depends on the live speech provider. Read capabilities instead of hardcoding rules. An unsupported target returns 422 unsupported_translation. For example, a provider can require an explicit language instead of auto, or allow only pairs that include English.
WebSocket protocol
Connecting
new WebSocket(websocket_url, ['t1-live', token]);The token travels as a WebSocket subprotocol. It never appears in a URL or an access log. Send no Authorization header: the token is the credential. The server pings every 30 seconds; browsers and the ws package answer automatically. A socket that sends no pong for 60 seconds is dropped.
A valid connection to a session that already has a socket replaces the old one. See Resume and takeover.
Client messages
| Message | Meaning |
|---|---|
| Binary frame | Audio: PCM s16le mono at the session's sample_rate. Each frame has an even length and is at most 256 KB. About 100 ms per frame is a good size. |
{"type":"finalize"} | Close the current line now, for example when the video is paused. |
{"type":"end"} | Finish the session and save it. The server answers session.ended and closes the socket. |
| Anything else | A non-fatal error event with the code invalid_message. |
An odd-length or oversized frame is a protocol violation and closes the socket with 4008.
Start sending audio after session.started. A client that sends audio faster than real time is billed for the audio time instead of the connected time (see Billing), so send at the speed the audio is produced.
Server events
Every event is a JSON text frame with a type.
| Event | Sent when |
|---|---|
session.started | The connection was accepted. Send audio from now on. |
line | A subtitle line was created or changed. |
usage | After every charge. |
warning | Something needs attention but the session continues. |
error | A non-fatal problem, or the reason for an immediate close. |
reconnect | The server is about to restart. Resume. |
session.ended | The session is over and saved. The last event before a normal close. |
session.started says whether this is a resume and how much audio the session has already processed:
{
"type": "session.started",
"session_id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
"transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
"resumed": false,
"offset_ms": 0
}line carries the full current state of one line, so replace the line you have with the same id. See The line object.
{
"type": "line",
"line": {
"id": 12,
"start_ms": 53120,
"end_ms": 56040,
"speaker": null,
"language": "en",
"text": "Welcome back to the stream",
"final": true,
"translation": { "language": "es", "text": "Bienvenidos de nuevo", "final": false }
}
}usage is sent after each charge, with the connected time, the audio time, the balance minutes charged so far and the minutes you have left:
{
"type": "usage",
"connected_ms": 61240,
"audio_ms": 60100,
"minutes_charged": 2,
"minutes_remaining": 898
}warning has a code:
code | Meaning |
|---|---|
balance_low | The next charge would fail. The session ends with balance_exhausted when it comes due. Not sent while auto top-up is on. |
audio_dropped | Audio arrived before the session was ready, or too much audio arrived while the speech provider connected. The oldest audio was dropped. |
idle_timeout_soon | No audio for 4 minutes. The session ends after 5 minutes without audio. Send audio to keep it. |
{ "type": "warning", "code": "idle_timeout_soon" }error has a code and a message. These codes can arrive on a live socket:
code | Fatal | Meaning |
|---|---|---|
invalid_message | No | A text frame that is not finalize or end. |
invalid_token | Yes | The token is invalid, expired or already used. The socket closes with 4001. |
not_found | Yes | The session does not exist. The socket closes with 4004. |
session_ended | Yes | The session has already ended. The socket closes with 4004. |
{ "type": "error", "code": "invalid_message", "message": "Expected {\"type\":\"finalize\"} or {\"type\":\"end\"}" }reconnect comes before the server closes the socket with 1012, for example during a deploy. Fetch a new token and resume.
{ "type": "reconnect", "reason": "server_restart" }session.ended is the last event of every session. The numbers are the final totals:
{
"type": "session.ended",
"reason": "client_ended",
"transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
"minutes_charged": 3,
"connected_ms": 142500,
"audio_ms": 141900
}The line object
| Field | Meaning |
|---|---|
id | A 0-based number that grows by one for each line. It is the segment index in the saved transcript. |
start_ms, end_ms | Start and end in session audio time. This time carries on across reconnects. |
speaker | The speaker label when diarize is on, otherwise null. Labels are the same as in normal transcripts. |
language | The detected or given language of the line, or null when it is not known yet. |
text | The original text so far. |
final | true once the original text will no longer change. |
translation | null without translate_to, otherwise language, text and final. |
Show lines with final: false in a lighter style: their text can still change. The text of a line with final: true never changes again, and a final line is never removed.
The SDK uses camelCase for line fields: startMs, endMs.
End reasons
session.ended and GET /v1/live/sessions/:id give an end_reason:
| Reason | Meaning |
|---|---|
client_ended | You sent end. |
balance_exhausted | The next charge failed. The session ends on a minute boundary and is saved. |
idle_timeout | No audio for 5 minutes. |
max_duration | The session was connected for 300 minutes. Start a new one. |
disconnected | The client was gone for more than 2 minutes without resuming. |
upstream_error | The speech provider failed. |
internal_error | Something went wrong on our side. |
not_connected | The session was never connected within 5 minutes. It is never charged and is visible only through GET. |
Close codes
| Code | Meaning |
|---|---|
1000 | Normal close. It always follows session.ended. |
1011 | Unexpected server error. The session is recovered by the server: resume with a new token. |
1012 | Server restart. It follows reconnect: resume with a new token. |
4001 | The token is invalid, expired or already used. |
4004 | The session was not found or has already ended. |
4008 | Protocol violation: an odd-length or oversized binary frame. |
4010 | Replaced by a newer connection to the same session. |
While the API restarts, a new WebSocket request can be answered with HTTP 503. Retry shortly.
Resume and takeover
A session survives a dropped network, an API deploy and a crash. For 2 minutes after a drop it waits for you; after that it ends as disconnected and is saved.
- Call
POST /v1/live/sessions/:id/token. The response istoken,token_expires_atandwebsocket_url, the same fields as when you created the session. It fails with409 session_endedonce the session has ended. - Open a new WebSocket with that token.
session.startedarrives withresumed: true.offset_msis the amount of audio the session has already processed: continue with the audio from that point. Line ids continue.
Lines that were already final are kept. The in-progress tail at the moment of the drop is lost, so replay a few seconds of audio from offset_ms if you buffer it. Reconnect gaps are not billed.
Takeover is the same call. If the server still thinks a socket is open, a new valid connection replaces it, and the old socket is closed with 4010 after its lines are saved. A resume is never blocked by the limit on simultaneous sessions.
The SDK does all of this for you: on a drop or a reconnect event it fetches a token and reconnects with a backoff of 0.5 to 8 seconds for up to 2 minutes, and replays the last 10 seconds of audio sent during the gap.
Billing
Live transcription draws from the same minute balance as batch transcriptions.
- Connected time is billed, from the moment the socket is accepted until it closes. Silence and a paused video count. The time between a drop and a resume does not.
- Per started minute, minimum 1. The first minute is charged when the first connection is accepted, and each following minute when it starts.
- Faster than real time is billed as audio time. If the audio you send is longer than the connected time, the audio time is billed instead.
- The rate is read from the API. One started minute costs
ratebalance minutes, rounded up. The rate is usually 1 and can have decimals. Read the live rate fromrates.liveinGET /v1/account, fromrateinGET /v1/live/capabilitiesand fromratein the session you create. The rate of a session stays fixed once it is created. - Translation is free.
- Never connected means not charged. A session that is not connected within 5 minutes ends as
not_connected. - Failures are refunded. If a session ends with
upstream_errororinternal_errorbefore it produced a single line, every charge is returned. An empty session caused by silence is charged and saved empty. - When the balance runs out and auto top-up is on, transcript1 charges your saved card while the session keeps streaming, and the session continues. If auto top-up is off or paused, or the charge fails, the session finalizes, saves and ends with
balance_exhaustedon a minute boundary. Thebalance_lowwarning comes first, unless auto top-up is on.
Creating a session returns 402 insufficient_minutes when your usable balance is lower than one started minute at the current rate. If auto top-up is on, transcript1 tries to top up first. See Billing and limits.
Limits
| Free | Monthly | Annual | |
|---|---|---|---|
| Simultaneous live sessions | 1 | 2 | 3 |
- A session counts while it is
waiting,connectedordisconnected(within its 2-minute window). Over the limit,POST /v1/live/sessionsreturns429 too_many_live_sessionswithRetry-After: 30. - Live sessions do not count toward your active job limit.
- A session lasts at most 300 minutes of connected time. It then ends with
max_duration. - A session with no audio for 5 minutes ends with
idle_timeout; you getidle_timeout_soonafter 4 minutes. A paused stream still bills connected time and counts as idle, so end the session if the pause is long. - transcript1 limits all live sessions together. When they are all in use,
POST /v1/live/sessionsreturns503 live_capacity, which is retryable. Wait forRetry-After(30 seconds) and try again. This is checked only when a session is created, never in the middle of one. - Creating sessions and fetching tokens count toward your requests per minute.
- Audio is raw PCM only:
pcm_s16le, mono, 8000 to 48000 Hz.
Capabilities
GET /v1/live/capabilities describes what live transcription supports right now, so clients never hardcode provider rules. The scope is transcriptions:read.
{
"available": true,
"auto_language": true,
"languages": ["en", "es"],
"translation": {
"targets": ["es", "de"],
"pairs": "any",
"requires_language": false
},
"diarize": true,
"audio_formats": ["pcm_s16le"],
"sample_rate": { "min": 8000, "max": 48000 },
"max_session_minutes": 300,
"rate": 1
}The lists are shortened here. Read them from the API.
translation.pairsis"any"(any detected language into any target), or a list of objects withfromandto.translation.requires_languageistruewhen translation needs an explicitlanguageinstead ofauto.- When live transcription is not configured, the response is
available: falsewith onlyrate, and creating a session returns503 live_unavailable.
With the SDK: await t1.live.capabilities().
Read a session
GET /v1/live/sessions/:id returns the state of a session. The scope is transcriptions:read.
curl https://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD \
-H "Authorization: Bearer $TRANSCRIPT1_API_KEY"{
"id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
"status": "ended",
"end_reason": "client_ended",
"transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
"options": {
"language": "en",
"translate_to": "es",
"diarize": false,
"audio_format": "pcm_s16le",
"sample_rate": 16000,
"save": true
},
"connected_ms": 142500,
"audio_ms": 141900,
"minutes_charged": 3,
"rate": 1,
"created_at": "2026-11-02T11:58:12Z",
"ended_at": "2026-11-02T12:00:35Z"
}status is waiting, connected, disconnected or ended. end_reason is null until the session has ended. With the SDK: await t1.live.get(id).
Errors
Request errors use the usual error envelope. These are the ones that POST /v1/live/sessions and its siblings return:
| HTTP status | code | Retryable | When |
|---|---|---|---|
| 422 | invalid_request | No | A field is missing or invalid, for example a sample_rate outside 8000 to 48000. |
| 422 | unsupported_language | No | language is not supported for live transcription. |
| 422 | unsupported_translation | No | The target language, or the language pair, is not supported. |
| 402 | insufficient_minutes | No | Your usable balance is below one started minute at the current rate. |
| 403 | email_not_verified | No | Verify your email address first. |
| 429 | too_many_live_sessions | Yes | Your plan's simultaneous live sessions are in use. Wait for Retry-After. |
| 503 | live_capacity | Yes | All live capacity is in use. Wait for Retry-After (30 seconds). |
| 503 | live_unavailable | No | Live transcription is not available right now. |
| 409 | session_ended | No | POST /v1/live/sessions/:id/token for a session that has ended. |
Problems on the socket arrive as error events and close codes.
Saved transcripts
With save: true (the default), the session is saved as a normal transcription when it ends. The audio is never stored.
transcription_idis returned when you create the session. Its status islivewhile the session runs andcompletedafter it ends. A session that never connected leaves acanceledtranscription.- The transcription has
source: { "platform": "live", "session_id": "lv_…" }. There is no link or file. The dashboard lists it as a live session. - Each line becomes one segment, with the same index as the line
id. Read the transcript as JSON, text, SRT or VTT like any other transcription: see the API reference. - The live translation is saved with it. Read it with the
translationparameter, as described in Translation. - Add more translation languages later with
POST /v1/transcriptions/:id/translationsonce the transcription iscompleted. See Translation.
With save: false, nothing is kept after the session ends: there is no transcription and transcription_id is null. You still get every line over the socket and the usual charge applies.
Security
- The token you get when you create a session is single-use and expires after 60 seconds. It is sent as a WebSocket subprotocol, not in the URL, so it stays out of access logs. A leaked token is useless once used or expired.
- Use an API key only from a server you control. Never put an API key into a web page or an extension.
- In a browser or an extension, sign users in with OAuth and pass the OAuth access token to
getAccessTokenofcreateLiveClient. The token needs thetranscriptions:writescope to start sessions. OAuth access tokens expire after 1 hour, and every resume asksgetAccessTokenfor a fresh one. - Your server can also create the session with its API key and hand only the
websocket_urlandtokento the browser. The client then connects directly. It cannot resume without a new token from you.
Updated November 3, 2026